#! /usr/bin/env python
#
# pytronome.py - a simple metronome tool
# version 1.1
# Homepage: http://code.google.com/p/eljunior-labs/
# Author: Elias Junior <eliasdorneles@gmail.com>
#
# Copyright 2007, 2008 Elias Junior
# 
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.


import sys, os, re, ossaudiodev, wave
from optparse import OptionParser

# this import is only needed for embedding wave files
# import base64, cStringIO

# global variables:
dsp = None
frames1 = frames2 = '\0'
max_to_read = 0

def abort(errmsg):
    sys.stderr.write('Error: ' + errmsg + '\n')
    sys.exit(1)

class Dsp:
    def __init__(self):
        self.dsp_handle = None;
        self.depth = 0
        self.channels = 0
        self.speed = 0

    def init(self, sampwidth, channels, samplerate):
        dsp_format = sampwidth * 8
        if dsp_format not in (ossaudiodev.AFMT_U8, ossaudiodev.AFMT_S16_LE):
            abort("only 8 and 16 bits per sample are supported")
        if channels not in (1,2):
            abort("number of channels must be 1 (mono) or 2 (stereo)")
        if samplerate < 8000 or samplerate > 96000:
            abort("samplerate must be between 8000 and 96000")

        # open the audio device
        dsp_handle = ossaudiodev.open('w')
        new_dsp_format = dsp_handle.setfmt(dsp_format)
        if new_dsp_format != dsp_format:
            abort("your dsp device does not seem to support format specified")

        dsp_channels = dsp_handle.channels(channels)
        if dsp_channels != channels:
            abort("channels: %d dsp_channels %d" % (channels, dsp_channels))

        dsp_speed = dsp_handle.speed(samplerate)
        if dsp_speed != samplerate:
            abort("different samplerates: will sound funny! ;)")
        self.dsp_handle = dsp_handle
        self.depth = dsp_format/8
        self.channels = dsp_channels
        self.speed = dsp_speed

    def close(self):
        if self.dsp_handle: self.dsp_handle.close()

    def write(self, data):
        if not self.dsp_handle: abort("dsp not properly initialized")
        count = len(data)
        written = self.dsp_handle.write(data)
        if written != len(data):
            abort("tried to write %d bytes, only %d written" % (written,count))

def le_waves():
    global dsp, frames1, frames2, max_to_read # globals
    # open wave files
    wav_tac = 'tac.wav'
    wav_tic = 'tic.wav'
    try:
        w1 = wave.open(wav_tac,'r')
    except:
        abort("error trying to open tac.wav")
    try:
        w2 = wave.open(wav_tic,'r')
    except:
        abort("error trying to open tic.wav")

    dsp = Dsp() # initialize dsp
    dsp.init(w1.getsampwidth(), w1.getnchannels(), w1.getframerate())
    max_to_read = dsp.depth * dsp.channels * dsp.speed / 2
    frames1 = w1.readframes(max_to_read)
    frames2 = w2.readframes(max_to_read)

    # some checks...
    # check sampler width and number of channels
    if w1.getsampwidth() != w2.getsampwidth():
        abort("both wave files must have the same sampler width")
    if w1.getnchannels() != w2.getnchannels():
        abort("both wave files must have the same number of channels")
    # read and checks done, may close waves now
    w1.close()
    w2.close()
    # check length of frames
    if len(frames1) < 10: abort("w1 too short")
    if len(frames2) < 10: abort("w2 too short")

# This is the most interesting function:
# this is where goes the code for the samples manipulation,
# and the program playing (as the name already told you ;)
def play_program(program):
    global dsp, frames1, frames2, max_to_read # globals! we know you love them!
    if len(program) == 0: program = [['4/4', 120, 0]] # safety defaults

    depth_chan = dsp.depth * dsp.channels
    prog_count = 0
    try:
        try:
            while prog_count < len(program):
                (meter, tempo, count) = program[prog_count]
                meter = [int(elem) for elem in meter.split('/')]

                dsp_patt_len = max_to_read * 120 / tempo
                if dsp_patt_len % depth_chan != 0:
                    # adjust pattern length
                    dsp_patt_len += depth_chan - dsp_patt_len % depth_chan

                # XXX: Time for some poor ad hoc string manipulation!
                # The problem here is that the method write() of an
                # ossaudiodev Audio Device doesn't accept a parameter for
                # how many bytes it should send, so we have to build
                # a string in the right size. Ugly, but works, and seems
                # to be unobstrusive. Let me know if you find a better way.
                if len(frames1) < dsp_patt_len:
                    frames1 += '\0' * (dsp_patt_len - len(frames1)) # pads...
                else:
                    frames1 = frames1[0:dsp_patt_len] # or slices
                if len(frames2) < dsp_patt_len:
                    frames2 += '\0' * (dsp_patt_len - len(frames2)) # pads...
                else:
                    frames2 = frames2[0:dsp_patt_len] # or slices (again!)

                infinite = False
                if count == 0: infinite = True
                while infinite or count > 0:
                    dsp.write(frames1) # tic
                    for i in range(meter[0]-1): dsp.write(frames2) # tac tac ...
                    count -= 1
                prog_count += 1
        except:
            print 'Exiting...'
    finally:
        dsp.close()

def parse_program_file(filename):
    program=[]
    try:
        try:
            f = open(filename)
            i = 0
            for line in f:
                i += 1
                # skip comments and blank lines
                if re.compile(r'^\s*(#.*)?$').match(line): continue
                # check syntax
                patt = re.compile(r'^\s*(\d\d?/\d\d?)\s+(\d+)(\s+\d+)?\s*$')
                m = patt.match(line)
                if not m:
                    abort('Program file has error in line: %d' % i)
                count = m.group(3)
                if not count: count = 0
                program.append([m.group(1), int(m.group(2)), int(count)])
        except:
            abort("problem dealing with input file: %s" % filename)
    finally:
        f.close()
    return program

def parse_options():
    parser = OptionParser(version="%prog   1.1")
    parser.add_option("-m", "--meter", dest="meter", default="4/4",
            help="define meter signature     default: 4/4")
    parser.add_option("-t", "--tempo", dest="tempo", default="120",
            help="beats per minute           default: 120")
    parser.add_option("-n", "--measures", dest="count", default="0",
            help="play just NUMBER measures then exit", metavar="NUMBER")
    parser.add_option("-p", "--program-file", dest="prog_file", default=None,
            help="play only the scripted in FILE", metavar="FILE")
    (options, args) = parser.parse_args()

    # do some basic checks...
    if options.prog_file:
        if not os.access(options.prog_file, os.F_OK | os.R_OK):
            abort("not possible to access file %s", options.prog_file)
    if not re.compile("^[0-9][0-9]?/[0-9][0-9]?$").match(options.meter):
        abort("meter must be in form NUMBER/NUMBER   e.g.: 3/4, 4/4 ...")
    try:
        options.tempo = int(options.tempo)
        if options.tempo < 40 or options.tempo > 240:
            raise Exception('TempoOutOfRange')
    except:
        abort("tempo must be a NUMBER, between 40 and 240")
    try:
        options.count = int(options.count)
    except:
        abort("NUMBER must be a number (duh!)")

    return options

if __name__ == '__main__':
    options = parse_options()
    le_waves()

    program = []
    if options.prog_file: # if given, read a program file
        program = parse_program_file(options.prog_file)
    if not program:
        program = [[options.meter, options.tempo, options.count]]
    print 'Hit Ctrl+C to stop'
    play_program(program)
